[slug].astro raw

   1  ---
   2  export const prerender = false;
   3  import Base from '../../layouts/Base.astro';
   4  import { getProductBySlug, firstImage, parseImages } from '../../lib/products';
   5  import { renderMarkdown } from '../../lib/markdown';
   6  
   7  const product = getProductBySlug(Astro.params.slug!);
   8  if (!product) {
   9    return new Response(null, { status: 404, statusText: 'Not Found' });
  10  }
  11  const images = parseImages(product.images);
  12  const mainImg = images[0] || '/images/logo.png';
  13  const stock = product.stock_count;
  14  const outOfStock = stock < 1;
  15  const maxQty = Math.min(stock, 99);
  16  ---
  17  
  18  <Base title={product.name} titleEn={product.name_en}>
  19    <main class="product-page">
  20      <section class="section">
  21        <div class="container">
  22          <nav class="breadcrumbs">
  23            <a href="/shop" data-i18n="backToShop">← Назад към магазина</a>
  24          </nav>
  25          <div class="product-detail">
  26            <div class="product-detail-image">
  27              <img src={mainImg} alt={product.name} id="main-image" class="gallery-main" />
  28              {outOfStock && (
  29                <span class="sold-out-badge" data-i18n="soldOut">Изчерпан</span>
  30              )}
  31              {images.length > 1 && (
  32                <div class="gallery-thumbs">
  33                  {images.map((img, i) => (
  34                    <img src={img} alt={product.name} class:list={['gallery-thumb', { active: i === 0 }]} data-full={img} />
  35                  ))}
  36                </div>
  37              )}
  38              <div id="admin-image-tools" style="display:none">
  39                <div class="gallery-thumbs" id="admin-thumbs"></div>
  40                <button type="button" class="ap-add-img" id="admin-upload-btn" title="Add image">
  41                  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
  42                </button>
  43                <input type="file" accept="image/*" id="admin-upload-input" style="display:none" />
  44              </div>
  45            </div>
  46            <div class="product-detail-info">
  47              <h1>{product.name}</h1>
  48              {product.name_en && (
  49                <p class="product-name-en">{product.name_en}</p>
  50              )}
  51              <p class="product-price-large">{(product.price_cents / 100).toFixed(2)} EUR</p>
  52              <p class="product-weight"><span data-i18n="productWeight">Грамаж</span>: {product.unit_size} <span data-i18n="unitG">гр.</span></p>
  53              {product.harvest_year && (
  54                <p class="product-harvest"><span data-i18n="productHarvest">Реколта</span>: {product.harvest_year}</p>
  55              )}
  56              <p class="product-stock"><span data-i18n="productStock">Наличност</span>: {stock} <span data-i18n="productBroy">бр.</span></p>
  57              {product.description && (
  58                <div class="product-description md-content" id="product-description" data-bg={product.description} data-en={product.description_en || product.description}>
  59                  <h4 data-i18n="adminProdDesc">Описание</h4>
  60                  <div class="box-content"><Fragment set:html={renderMarkdown(product.description)} /></div>
  61                </div>
  62              )}
  63              {product.usage && (
  64                <div class="product-usage md-content" id="product-usage" data-bg={product.usage} data-en={product.usage_en || product.usage}>
  65                  <h4 data-i18n="productUsage">Препоръка за употреба</h4>
  66                  <div class="box-content"><Fragment set:html={renderMarkdown(product.usage)} /></div>
  67                </div>
  68              )}
  69              <div class="product-actions">
  70                <button class="btn btn-primary btn-lg" id="add-to-cart-btn"
  71                  data-slug={product.slug}
  72                  data-name={product.name}
  73                  data-name-en={product.name_en}
  74                  data-price={product.price_cents}
  75                  data-image={product.images}
  76                  data-unit-size={product.unit_size}
  77                  data-unit={product.unit}
  78                  data-stock={stock}
  79                  disabled={outOfStock}>
  80                  <span>{outOfStock ? 'Изчерпан' : 'ДОБАВИ В КОЛИЧКА'}</span>
  81                </button>
  82              </div>
  83            </div>
  84          </div>
  85        </div>
  86      </section>
  87    </main>
  88  </Base>
  89  
  90  <script>
  91  import { addToCart, getCart } from '../../lib/cart-client';
  92  import { t, currentLang } from '../../lib/i18n';
  93  import { renderMarkdown } from '../../lib/markdown';
  94  
  95  const addBtn = document.getElementById('add-to-cart-btn')! as HTMLButtonElement;
  96  const stock = parseInt(addBtn.dataset.stock!) || 0;
  97  
  98  // language-aware description/usage boxes
  99  const descEl = document.getElementById('product-description');
 100  const usageEl = document.getElementById('product-usage');
 101  
 102  function applyBoxLang() {
 103    const lang = currentLang();
 104    if (descEl) {
 105      const md = lang === 'en' ? (descEl.dataset.en || descEl.dataset.bg!) : descEl.dataset.bg!;
 106      const h4 = descEl.querySelector('h4');
 107      const old = descEl.querySelector('.box-content');
 108      old?.remove();
 109      const div = document.createElement('div');
 110      div.className = 'box-content';
 111      div.innerHTML = renderMarkdown(md);
 112      descEl.insertBefore(div, h4?.nextSibling || null);
 113    }
 114    if (usageEl) {
 115      const md = lang === 'en' ? (usageEl.dataset.en || usageEl.dataset.bg!) : usageEl.dataset.bg!;
 116      const h4 = usageEl.querySelector('h4');
 117      const old = usageEl.querySelector('.box-content');
 118      old?.remove();
 119      const div = document.createElement('div');
 120      div.className = 'box-content';
 121      div.innerHTML = renderMarkdown(md);
 122      usageEl.insertBefore(div, h4?.nextSibling || null);
 123    }
 124  }
 125  applyBoxLang();
 126  window.addEventListener('lang-changed', applyBoxLang);
 127  
 128  // admin image tools: upload directly to site db
 129  (async () => {
 130    try {
 131      const token = localStorage.getItem('token');
 132      if (!token) return;
 133      const r = await fetch('/api/auth/session', { headers: { 'Authorization': 'Bearer ' + token } });
 134      const d = await r.json();
 135      if (!d.user?.is_admin) return;
 136      const tools = document.getElementById('admin-image-tools')!;
 137      tools.style.display = 'block';
 138      const slug = addBtn.dataset.slug!;
 139      const prodList = await fetch('/api/products').then(r => r.json());
 140      const product = Array.isArray(prodList) ? prodList.find((p: any) => p.slug === slug) : null;
 141      if (!product) return;
 142      let imgs: string[] = [];
 143      try { imgs = JSON.parse(product.images || '[]'); } catch {}
 144      const thumbs = document.getElementById('admin-thumbs')!;
 145      function renderAdminThumbs() {
 146        thumbs.innerHTML = imgs.map((img, i) => `<div style="position:relative;display:inline-block"><img src="${img}" class="gallery-thumb" style="width:64px;height:64px;object-fit:cover;border-radius:6px;border:2px solid transparent" /><button type="button" data-i="${i}" style="position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:9px;border:none;background:rgba(200,0,0,0.85);color:#fff;font-size:11px;cursor:pointer;line-height:1;padding:0">&times;</button></div>`).join('');
 147        thumbs.querySelectorAll('button').forEach(b => {
 148          b.addEventListener('click', async () => {
 149            const i = parseInt((b as HTMLElement).dataset.i!);
 150            imgs.splice(i, 1);
 151            await saveImages(imgs);
 152            renderAdminThumbs();
 153          });
 154        });
 155      }
 156      async function saveImages(list: string[]) {
 157        const p: any = product;
 158        await fetch(`/api/admin/products/${p.id}`, {
 159          method: 'PUT',
 160          headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
 161          body: JSON.stringify({
 162            name: p.name || '',
 163            name_en: p.name_en || '',
 164            slug: p.slug || '',
 165            description: p.description || '',
 166            description_en: p.description_en || '',
 167            price_cents: p.price_cents,
 168            unit_size: p.unit_size,
 169            unit: p.unit || 'g',
 170            images: JSON.stringify(list),
 171            category: p.category || 'bilki',
 172            stock_count: p.stock_count || 0,
 173            harvest_year: p.harvest_year || null,
 174            usage: p.usage || '',
 175            usage_en: p.usage_en || '',
 176          }),
 177        });
 178      }
 179      renderAdminThumbs();
 180      const uploadBtn = document.getElementById('admin-upload-btn')!;
 181      const uploadInput = document.getElementById('admin-upload-input') as HTMLInputElement;
 182      uploadBtn.addEventListener('click', () => uploadInput.click());
 183      uploadInput.addEventListener('change', async () => {
 184        const file = uploadInput.files?.[0];
 185        if (!file) return;
 186        const form = new FormData();
 187        form.append('file', file);
 188        const ur = await fetch('/api/admin/upload', { method: 'POST', headers: token ? { 'Authorization': 'Bearer ' + token } : {}, body: form });
 189        const ud = await ur.json();
 190        if (!ur.ok) { alert(ud.error || 'Upload failed'); return; }
 191        if (ud.url) {
 192          imgs.push(ud.url);
 193          await saveImages(imgs);
 194          renderAdminThumbs();
 195        }
 196      });
 197    } catch {}
 198  })();
 199  
 200  function updateBtnState() {
 201    if (stock < 1) return;
 202    const cart = getCart();
 203    const inCart = cart.find(it => it.slug === addBtn.dataset.slug!);
 204    if (inCart && inCart.qty >= stock) {
 205      addBtn.disabled = true;
 206      addBtn.innerHTML = `<span data-i18n="noMoreAvailable">${t('noMoreAvailable')}</span>`;
 207    } else {
 208      addBtn.disabled = false;
 209      addBtn.innerHTML = `<span data-i18n="addToCart">${t('addToCart')}</span>`;
 210    }
 211  }
 212  
 213  updateBtnState();
 214  window.addEventListener('cart-updated', updateBtnState);
 215  
 216  addBtn.addEventListener('click', (e) => {
 217    const btn = e.currentTarget as HTMLButtonElement;
 218    if (btn.disabled) return;
 219    addToCart({
 220      slug: btn.dataset.slug!,
 221      name: btn.dataset.name!,
 222      name_en: btn.dataset.nameEn || btn.dataset.name!,
 223      price_cents: parseInt(btn.dataset.price!),
 224      image: btn.dataset.image!,
 225      unit_size: parseInt(btn.dataset.unitSize!) || 100,
 226      unit: btn.dataset.unit! || 'g',
 227      stock: parseInt(btn.dataset.stock!) || 0,
 228    }, 1);
 229    btn.textContent = '✓';
 230    setTimeout(() => updateBtnState(), 1000);
 231  });
 232  
 233  // gallery: thumbnail click swaps main image
 234  const mainImg = document.getElementById('main-image')! as HTMLImageElement;
 235  document.querySelectorAll('.gallery-thumb').forEach(thumb => {
 236    thumb.addEventListener('click', () => {
 237      const el = thumb as HTMLImageElement;
 238      mainImg.src = el.dataset.full || el.src;
 239      document.querySelectorAll('.gallery-thumb').forEach(t => t.classList.remove('active'));
 240      el.classList.add('active');
 241    });
 242  });
 243  
 244  // lightbox: click main image opens overlay
 245  mainImg.addEventListener('click', () => {
 246    let lb = document.getElementById('lightbox');
 247    if (!lb) {
 248      lb = document.createElement('div');
 249      lb.id = 'lightbox';
 250      lb.className = 'lightbox';
 251      lb.innerHTML = `
 252        <button class="lightbox-close">&times;</button>
 253        <img class="lightbox-img" src="" />
 254      `;
 255      document.body.appendChild(lb);
 256      lb.querySelector('.lightbox-close')!.addEventListener('click', () => lb!.classList.remove('open'));
 257      lb.addEventListener('click', (e) => { if (e.target === lb) lb!.classList.remove('open'); });
 258      document.addEventListener('keydown', (e) => { if (e.key === 'Escape') lb!.classList.remove('open'); });
 259    }
 260    (lb.querySelector('.lightbox-img') as HTMLImageElement).src = mainImg.src;
 261    lb.classList.add('open');
 262  });
 263  </script>
 264  
 265  <style is:global>
 266    .breadcrumbs { margin-bottom: 2rem; }
 267    .breadcrumbs a { color: var(--primary); text-decoration: none; }
 268    .product-detail {
 269      display: grid;
 270      grid-template-columns: 1fr 1fr;
 271      gap: 3rem;
 272      align-items: start;
 273    }
 274    .gallery-main {
 275      width: 100%;
 276      border-radius: 12px;
 277      cursor: zoom-in;
 278    }
 279    .product-detail-image { position: relative; }
 280    .product-detail-image .sold-out-badge {
 281      position: absolute;
 282      top: 12px;
 283      right: 12px;
 284      background: #c0392b;
 285      color: #fff;
 286      font-size: 0.85rem;
 287      font-weight: 700;
 288      text-transform: uppercase;
 289      padding: 6px 14px;
 290      border-radius: 4px;
 291      letter-spacing: 0.5px;
 292      z-index: 2;
 293    }
 294    .gallery-thumbs {
 295      display: flex;
 296      flex-wrap: wrap;
 297      gap: 8px;
 298      margin-top: 12px;
 299    }
 300    .gallery-thumb {
 301      width: 64px;
 302      height: 64px;
 303      object-fit: cover;
 304      border-radius: 6px;
 305      border: 2px solid transparent;
 306      cursor: pointer;
 307      transition: border-color 0.2s;
 308    }
 309    .gallery-thumb:hover { border-color: var(--primary); }
 310    .gallery-thumb.active { border-color: var(--primary); opacity: 0.6; }
 311    .lightbox {
 312      display: none;
 313      position: fixed;
 314      inset: 0;
 315      background: rgba(0,0,0,0.9);
 316      z-index: 9999;
 317      align-items: center;
 318      justify-content: center;
 319    }
 320    .lightbox.open { display: flex; }
 321    .lightbox-img {
 322      max-width: 90vw;
 323      max-height: 90vh;
 324      object-fit: contain;
 325      border-radius: 8px;
 326    }
 327    .lightbox-close {
 328      position: absolute;
 329      top: 20px;
 330      right: 30px;
 331      background: none;
 332      border: none;
 333      color: #fff;
 334      font-size: 36px;
 335      cursor: pointer;
 336      line-height: 1;
 337      z-index: 1;
 338    }
 339    .product-name-en {
 340      font-size: 1rem;
 341      color: var(--text-light);
 342      font-style: italic;
 343      margin-bottom: 0.5rem;
 344    }
 345    .product-price-large {
 346      font-size: 1.5rem;
 347      font-weight: 700;
 348      color: var(--primary);
 349      margin: 1rem 0 0;
 350    }
 351    .product-weight {
 352      font-size: 0.9rem;
 353      color: var(--text-light);
 354      margin: 0 0 0.5rem;
 355    }
 356    .product-harvest {
 357      font-size: 0.9rem;
 358      color: var(--text-light);
 359      margin-bottom: 0.3rem;
 360    }
 361    .product-description {
 362      margin: 1rem 0 1.5rem;
 363      text-align: justify;
 364    }
 365    .product-description h4 {
 366      margin: 0 0 0.5rem;
 367      font-size: 0.9rem;
 368      color: var(--primary);
 369    }
 370    .product-stock {
 371      font-size: 0.9rem;
 372      color: var(--text-light);
 373      margin-bottom: 0.5rem;
 374    }
 375    .product-usage {
 376      margin: 0 0 1.5rem;
 377      padding: 1rem;
 378      background: var(--bg-alt, #f5f5f5);
 379      border-radius: 8px;
 380      text-align: justify;
 381    }
 382    .product-usage h4 {
 383      margin: 0 0 0.5rem;
 384      font-size: 0.9rem;
 385      color: var(--primary);
 386    }
 387    .product-usage p { font-size: 0.9rem; }
 388    .product-actions {
 389      display: flex;
 390      gap: 1rem;
 391      align-items: center;
 392    }
 393    #add-to-cart-btn { flex: 1; }
 394    #add-to-cart-btn:disabled { opacity: 0.45; cursor: default; }
 395    @media (max-width: 768px) {
 396      .product-detail { grid-template-columns: 1fr; }
 397    }
 398  </style>
 399